Skip to main content

11 IO

这篇笔记介绍lecture18的内容,即系统级I/O。I/O指软件和外界设备之间的数据交流。标准I/O库指用户态或更高级的I/O函数,而Unix I/O指内核态提供的I/O函数。

文件

Unix中的文件是一系列字节。所有I/O设备都被视作文件,使得Unix能提供一个简单的应用程序接口。所有输入输出都通过读写相应文件来运行。

目录结构

Linux内核将所有文件组织在一个单一的目录层次结构中,以根目录(/)为锚点。每个进程都有当前的工作目录。

文件类型

常规文件可以包含任意数据。它分为文本文件(只包含ASCII或Unicode字符)和二进制文件(其他)两类,这些文件从内核角度上看没有区别。另外,文本文件的每行以换行符结束(LF),值为0x0a。

目录是包含一个链接(文件入口)数组的文件,每个链接将一个文件名映射到一个文件。每个目录至少包含两个文件入口,分别是 ...

套接字(socket)是一个用于通过网络与其它进程通信的文件。

此外,还有命名管道(pipes)、符号链接、字符和块设备等文件。

文件操作

打开文件

当某个应用程序需要获取I/O设备时,内核会打开相关文件、返回描述符(一个小的非负整数,在文件的所有后续操作中标识该文件)并跟踪这个文件的所有信息(为所有打开的文件维护一个文件位置k,初值为0)。

为了实现这些操作,内核需要知道返回哪个整数,还需要知道在存储文件位置(cursor)、文件是否存在、文件大小等信息。这些信息分别通过进程ID池、打开文件的数据结构、文件系统、元数据等获得。

kernel ds for files

每个进程都有独立的描述符表,描述表的条目由进程的打开文件描述符索引。每个描述符指向文件表中的一个条目。

文件表表示打开文件的集合,被所有进程共享。每个文件表条目包含当前文件位置、指向它的描述符条目的引用数量、一个指向v节点表中条目的指针。当引用数量变为0时,在文件表中删除这个条目。

v节点表也由所有进程共享,每个条目包含一个文件的主要信息。它位于主内存中(元数据被缓存)。

在应用程序获取I/O设备时,它只保存描述符。通过 seek 操作,应用程序可以显示设置当前文件位置。

// return new file descriptor if OK, -1 on error
int open(char *filename, int flags, mode_t mode);

flags and modes

可以同时使用多个flags和mode,用管道符连接。

通过 umask 函数可以为进程设置掩码,在创建文件时,掩码中的权限会被屏蔽。

#define DEF_MODE S_IRUSR | S_IWUSR |	\
S_IRGRP | S_IWGRP | \
S_IROTH | S_IWOTH
#define DEF_UMASK S_IWGRP | S_IWOTH

umask(DEF_UMASK);
fd = open (“foot.txt”, O_CREAT|O_TRUNC|O_WRONLY, DEF_MODE);

例如,这里 foot.txt 文件的权限是 DEF_MODE & (~DEF_UMASK)

关闭文件

关闭文件时,内核释放打开文件时创建的结构,并将描述符恢复到可用描述池。下一个打开的文件会接收到池中最小的可用描述符。

终止时,内核的默认动作是关闭所有打开的文件并释放它们的内存资源。

// return 0 if OK, -1 on error
int close(int fd);

读写操作

读写操作的本质是将复制字符,读是复制字符到 buf ,写则反之。

// number of bytes if OK, 0 on EOF, -1 on error
ssize_t read(int fd, void *buf, size_t count);

// number of bytes if OK, -1 on error
ssize_t write(int fd, const void *buf, size_t count);

注意这里的返回值类型是 ssize_t 而不是 size_t ,因为返回值可能是负数。

读取到file position km 个字节的位置。如果 size (存储在原数据中)小于需要读取的字符数,触发EOF,并返回short count。此外,从终端或网络读取数据也会返回short count。

Lseek

// new cursor position if OK, -1 on error
off_t lseek(int fd, off_t offset, int whence);

这个函数可以调整文件位置值。 fd 代表需要修改的文件, offset 指偏移量(可以是负数), whence 代表起始位置(SEEK_SET、SEEK_CUR、SEEK_END分别对应文件开始、当前位置、结尾)。注意文件位置是第一个被读取的位置,从0开始。

int main()
{
int fd;
char c;

fd = open(“foobar.txt”, O_RDONLY, 0);
lseek(fd, 3, SEEK_SET);
read(fd, &c, 1);
printf("char = %c\n", c);
exit(0);
}

foobar.txt 中存储的内容是“foobar”,上面代码的输出是‘b’。

元数据

元数据存储文件本身的一些信息。操作系统提供一些获取元数据的接口。

// 0 if OK, -1 on error
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);

这两个函数会将对应文件的元数据信息复制到 buf 参数的位置。获取了文件的元数据后,可以用各种宏来检查。

读写目录

目录由数个 dirent (directory entry)组成。

struct dirent {
ino_t d_ino; /*inode number*/
char d_name[256]; /*file name */
};

以下是相关函数。

// pointer if OK, NULL on error
DIR *opendir(const char *filename);
// pointer to next dirent if OK, NULL if no more or error
struct dirent *readdir(DIR *dirp);
// 0 if OK, -1 on error
int closedir(DIR *dirp);

共享文件

在每次打开文件时,系统会进行完全相同的操作。也就是说,会有新的描述符和对应的文件表。

open twice and read

int main()
{
int fd1, fd2;
char c;

fd1 = open(“foobar.txt”, O_RDONLY, 0);
fd2 = open(“foobar.txt”, O_RDONLY, 0);
read(fd1, &c, 1);
read(fd2, &c, 1);
printf(char = %c\n”, c);
exit(0);
}

上面的代码会输出‘f’。

在创建子进程时,子进程会得到从父进程复制过来的描述符表,但是不会创建新的文件表,只是指向文件表的refcnt会加一。

fork and read

int main()
{
int fd;
char c;

fd = open(“foobar.txt”, O_RDONLY, 0);
if (fork() == 0 ) {
read(fd, &c, 1);
exit(0);
}
wait(NULL);
read(fd, &c, 1);
printf(char = %c\n”, c);
exit(0)
}

上面的代码会输出‘o’。

重定向

// new descriptor if OK, -1 on error
int dup2(int oldfd, int newfd);

这个函数会将 newfd 指向的文件表改为 oldfd 指向的文件表。如果 newfd 原来指向的文件表的 refcnt 为0,会被操作系统回收(v节点表也可能被回收,但大多数时候不会,下图黄色部分是可能被回收的部分)。

dup example

还有一个函数可以实现类似“恢复”重定向的功能。

// new descriptor if OK
int dup(int oldfd);

调用这个函数时,操作系统会选择一个 newfd (通常是空着的最小 fd ),将 oldfd 覆盖给 newfd ,并返回 newfd

利用这两个函数可以给shell增加重定向功能。

fd = open(“foot.txt”, O_WRONLY | O_CREAT | O_TRUNC);
save_stdout = dup(stdout);

dup2(fd, stdout);
if (pid = fork() == 0) execve(“ls”, argv, environ);
waitpid(pid, NULL, 0);

dup2(save_stdout, stdout);
close(save_stdout);

另外,以上只是简单的写法。 stdoutFILE* 类型,需要用 fileno(stdout) 作为参数。